#!/bin/bash

#  git-graph - print pretty git commit logs
#
#  Copyright 2016-2018,2020,2023-2024 bill-auger <https://github.com/bill-auger>
#
#  git-graph is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  git-graph is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License version 3
#  along with git-graph.  If not, see <http://www.gnu.org/licenses/>.


## configuration ##

# set $PUB_BRANCH in the environment or via the `-t` arg
#   eg: 'master', 'upstream/stable-1.0'
# $PUB_BRANCH defaults to the remote tracking branch, if undefined
PUB_BRANCH=${PUB_BRANCH:-}


## constants ##

readonly DEF_HASH_LEN=7 # NOTE: git may extend this to the minimum unique prefix
readonly JOIN_CHAR='`'
readonly HRULE_CHAR='-'
readonly GRAPH_REGEX="(.+)"
readonly ID_REGEX="(.+)"
readonly DATE_REGEX="(.+)"
readonly AUTHOR_REGEX="(.+)"
readonly SIG_REGEX="\[(([^\(\)]*) + *\(?.*\)? *<.*>|)\]"
readonly STAT_REGEX="\[(.)\]"
readonly MSG_REGEX="(.*)"
readonly REFS_REGEX="\((.*)\)"
readonly GIT_LOG_FMT="%%h${JOIN_CHAR}%%as${JOIN_CHAR}%%an${JOIN_CHAR}[%%GS]${JOIN_CHAR}[%%G?]${JOIN_CHAR}%%s${JOIN_CHAR}(%%D)"
readonly GIT_LOG_CMD_FMT="git log --graph --date=short -n %d --pretty=format:$GIT_LOG_FMT --abbrev=${DEF_HASH_LEN}"
readonly LOG_REGEX="^${GRAPH_REGEX} ${ID_REGEX}${JOIN_CHAR}${DATE_REGEX}${JOIN_CHAR}${AUTHOR_REGEX}${JOIN_CHAR}${SIG_REGEX}${JOIN_CHAR}${STAT_REGEX}${JOIN_CHAR}${MSG_REGEX}${JOIN_CHAR}${REFS_REGEX}$"
readonly CWHITE='\033[0;37m'
readonly CGREEN='\033[0;32m'
readonly CYELLOW='\033[0;33m'
readonly CRED='\033[0;31m'
readonly CAQUA='\033[1;36m'
readonly CEND='\033[0m'
readonly CGOOD=$CGREEN
readonly CUNKNOWN=$CYELLOW
readonly CEXPIRED=$CYELLOW
readonly CBAD=$CRED
readonly CNONE=$CWHITE
readonly HASH_COLOR=$CNONE
readonly DATE_COLOR=$CNONE
readonly AUTHOR_COLOR=$CNONE
readonly MSG_COLOR=$CNONE
readonly REF_COLOR=$CAQUA
readonly REF_ERR_FMT="No such ref or file: %s\nSee \`$(basename $0) -h\` for usage.\n"
declare -i USE_ANSI_COLOR=1 # (deferred)
declare -i N_COMMITS=12     # (deferred)
declare -i HIDE_MERGED=0    # (deferred)
REF=                        # (deferred)
FILE=                       # (deferred)
LOG_FILE=                # (deferred)


## variables ##

declare -a Graphs=()
declare -a Ids=()
declare -a Dates=()
declare -a Authors=()
declare -a Sigs=()
declare -a Stats=()
declare -a Msgs=()
declare -a Refs=()
declare -a SigColors=()
declare -i AuthorW=0
declare -i NCommits=0


## helpers ##

GetUpstreamBranch() # ( local_branch )
{
  local local_branch=$1

  git rev-parse --abbrev-ref $local_branch@{upstream} 2> /dev/null
}

GetCurrentBranch()
{
  git rev-parse --abbrev-ref HEAD
}

DoesBranchExist() # ( branch_name )
{
  local branch_name=$1

  [[ "$branch_name" && "$(git branch --all --list $branch_name)" ]]
}

DoesTagExist() # ( tag_name )
{
  local tag_name=$1

  [[ "$tag_name" && "$(git tag | grep -G "$tag_name$")" ]]
}

DoesCidExist() # ( tag_name )
{
  local commit_id=$1

  [[ "$commit_id" ]] && git rev-parse --verify ${commit_id}^{commit} &> /dev/null
}

ValidateRef() # ( ref ) # where ref is a branch_name, tag_name, commit_id
{
  local ref=$1

  DoesBranchExist $ref || DoesTagExist $ref || DoesCidExist $ref || ref=''

  echo $ref ; [[ -n "$ref" ]] ;
}

ValidateParam() # ( "param" ) # where param is a file, directory, or ref as ValidateRef()
{
  local param="$1"

  param="$( ValidateRef $param                   ||
          ( [[ -f "$param" ]] && echo $param   ) ||
          ( [[ -d "$param" ]] && echo $param/* )  )"

  echo $param ; [[ -n "$param" ]] ;
}

IsAncestor() # ( ref_a ref_b )
{
  local ref_a=$1
  local ref_b=$2

  git merge-base --is-ancestor $ref_a $ref_b
}

Ancestor() # ( ref_a ref_b )
{
  local ref_a=$1
  local ref_b=$2

  git merge-base $ref_a $ref_b 2> /dev/null
}

JoinChars() # ( "a_spaced_string" )
{
  local a_string="$1"

  echo "${a_string// /$JOIN_CHAR}"
}

FilterJoinChars() # ( an_unspaced_string )
{
  local a_string=$1

  echo $a_string | tr "$JOIN_CHAR" " "
}

Cleanup()
{
  rm "$LOG_FILE"
}


## business ##

Usage()
{
  cat <<EOF
USAGE:
  git-graph [ -a | -h | -m | -n <N_COMMITS> | -u ] [ <REF> | <FILE> ]

    where: <REF> is for example: [ branch_name | tag_name | commit_hash ]
           <FILE> is one of the files or directories under version control

  If <REF> is unspecified, it will be the current HEAD.
  If <FILE> is unspecified, all files under version control will be considered.

  Note: Shell globbing is normally expanded by your shell before passing
        arguments to programs, and so may include files which are not under
        version control; which can lead to errors or inaccurate results. Try to
        avoid asterisks and other glob operators in the arguments.

Options:
  -a              List all log entries leading up to <REF> which touched <FILE>.
  -h              Show this help message.
  -m              Machine-readable. Do not output ANSI color codes.
  -n <N_COMMITS>  List the most recent <N_COMMITS> number of log entries leading
                  up to <REF> which touched <FILE> (default: $N_COMMITS).
  -t              Specify the trunk branch to be considered as the merge base.
  -u              List only unmerged log entries, those not present on the
                  remote tracking branch (or \$PUB_BRANCH, if defined)
                  (currently: ${PUB_BRANCH:-undefined}).
EOF
}

Init() # ( cli_args* )
{
  local arg
  local valid_param
  local valid_ref
  local is_valid_param
  local is_valid_ref
  local is_file

  # parse cli args
  while getopts 'ahmn:t:u' arg
  do    case "${arg}" in
             a) N_COMMITS="$(git rev-list --count HEAD)" ;; # a -> report All
             h) Usage ; exit 0 ;                         ;; # h -> Help
             m) USE_ANSI_COLOR=0                         ;; # m -> Machine-readable
             n) N_COMMITS="${OPTARG}"                    ;; # n -> Number of entries
             t) PUB_BRANCH="${OPTARG}"                   ;; # m -> Trunk (merge-base)
             u) HIDE_MERGED=1                            ;; # u -> Unmerged only
             *) echo "Invalid argument: '${arg}'"        ;;
        esac
  done
  shift $(( OPTIND - 1 ))

  # process cli args
  valid_param=$(ValidateParam "$1")
  valid_ref=$(  ValidateRef   "$1")
  is_valid_param=$( [[ -n "$valid_param"                ]] ; echo $((!$?)) ; )
  is_valid_ref=$(   [[ -n "$valid_ref"                  ]] ; echo $((!$?)) ; )
  is_file=$(        (( is_valid_param && ! is_valid_ref )) ; echo $((!$?)) ; )
  REF=$( (( is_file || ! $# )) && echo HEAD         || echo $valid_ref)
  FILE=$((( is_file         )) && echo $valid_param || echo $2        )
  PUB_BRANCH=${PUB_BRANCH:-$(GetUpstreamBranch $(GetCurrentBranch))}

  # setup temp file
  LOG_FILE=git-graph-errs # TODO: make this a tmp file
  trap 'Cleanup' EXIT

  # finalize state
  readonly USE_ANSI_COLOR
  readonly N_COMMITS
  readonly HIDE_MERGED
  readonly REF
  readonly FILE
  readonly PUB_BRANCH
  readonly LOG_FILE


# echo "is_valid_param=$is_valid_param is_valid_ref=$is_valid_ref is_file=$is_file REF=$REF FILE=$FILE" # DEBUG


  (( ! $# )) || (( is_valid_param )) || ! printf "$REF_ERR_FMT" "$1"
}

CompileResults()
{
  local log_data graph id date author sig stat msg ref

  # reset data
  Graphs=() Ids=() Dates=() Authors=() Sigs=() Stats=() Msgs=() Refs=() SigColors=()

  # compile results
  while read -r log_data
  do    [[ $log_data =~ $LOG_REGEX ]] || continue


# TODO: graph colors and fork/merge node lines
# printf "log_data='%s'\n" "$log_data" ; [[ $log_data =~ $LOG_REGEX ]] && printf "graph='%s'\n" "${BASH_REMATCH[1]}" || printf "graph NFG='%s'\n" "$log_data"


        graph=${BASH_REMATCH[  1]} ; Graphs=( ${Graphs[*]}  $(JoinChars "$graph" )) ;
        id=${BASH_REMATCH[     2]} ; Ids=(    ${Ids[*]}     $(JoinChars "$id"    )) ;
        date=${BASH_REMATCH[   3]} ; Dates=(  ${Dates[*]}   $(JoinChars "$date"  )) ;
        author=${BASH_REMATCH[ 4]} ; Authors=(${Authors[*]} $(JoinChars "$author")) ;
        sig=${BASH_REMATCH[    6]} ; Sigs=(   ${Sigs[*]}    $(JoinChars "$sig"   )) ;
        stat=${BASH_REMATCH[   7]} ; Stats=(  ${Stats[*]}   $(JoinChars "$stat"  )) ;
        msg=${BASH_REMATCH[    8]} ; Msgs=(   ${Msgs[*]}    $(JoinChars "$msg"   )) ;
        ref=${BASH_REMATCH[    9]} ; Refs=(   ${Refs[*]}    $(JoinChars "$ref"   )) ;
        [[ -n "$msg"      ]] ||      Msgs=(   ${Msgs[*]}    "<EMPTY>"             )
        [[ "$stat" == 'E' ]] &&      Sigs=(   ${Sigs[*]}    "<UNKNOWN>"           ) || \
        [[ -n "$sig"      ]] ||      Sigs=(   ${Sigs[*]}    "$JOIN_CHAR"          )
        [[ -n "$ref"      ]] ||      Refs=(   ${Refs[*]}    "$JOIN_CHAR"          )

        (( ${#author} > AuthorW )) && AuthorW=${#author}
  done
}

GitLog() # ( n_commits range file )
{
  declare -i n_commits=$1
  local range=$2
  local file=$3

  $(printf "$GIT_LOG_CMD_FMT" $n_commits) $range $file 2>> "$LOG_FILE"
}

PrintReport() # ( header... )
{
  local header="$@"
  local n_results=$(( ${#Ids[*]} ))
  local pad_w=$(( ( -${#header} + ${#Ids[0]} + 1 + ${#Dates[0]} + AuthorW ) / 2 ))
  local pad="$(printf "%${pad_w}s" ' ' | tr ' ' "$HRULE_CHAR")"
  local hrule="|<${pad} ${header} ${pad:$(( pad_w > 0 && ! ( AuthorW % 2 ) ))}>|"

  local result_n graph id date author sig stat msg ref pad
  local has_author_sig sig_color hash_color date_color author_color msg_color ref_color

  # pretty print results
  (( ! HIDE_MERGED )) && echo "${hrule}"
  (( ! n_results   )) && sed 's/[^|]/ /g ; s/^|       /| <None>/' <<<"${hrule}"
  for (( result_n = 0 ; result_n < n_results ; ++result_n ))
  do  graph=${Graphs[$result_n]}
      id=${Ids[$result_n]}
      date=${Dates[$result_n]}
      author=${Authors[$result_n]}
      sig=${Sigs[$result_n]}
      stat=${Stats[$result_n]}
      msg=${Msgs[$result_n]}
      ref=${Refs[$result_n]}
      pad=$(printf "%$(( AuthorW - ${#author} ))s" '')

      if   (( USE_ANSI_COLOR ))
      then has_author_sig=$([[ "$author" == "$sig" ]] ; echo $((!$?)) ;)
           sig_color=$(case "$stat" in
                            'G') echo $CGOOD    ;; # good signature
                            'X') echo $CEXPIRED ;; # good signature that has expired
                            'U') echo $CGOOD    ;; # good signature with unknown trust
                            'E') echo $CUNKNOWN ;; # cannot be checked (e.g. missing key)
                            'B') echo $CBAD     ;; # bad signature
                            'Y') echo $CEXPIRED ;; # good signature made by an expired key
                            'R') echo $CBAD     ;; # good signature made by a revoked key
                            'N') echo $CNONE    ;; # no signature
                       esac)
           hash_color=$HASH_COLOR
           date_color=$DATE_COLOR
           author_color=$((( has_author_sig )) && echo $sig_color || echo $AUTHOR_COLOR)
           msg_color=$MSG_COLOR
           ref_color=$REF_COLOR
      fi

#     printf "$graph_color$(FilterJoinChars $graph) $CEND"
      printf "| $hash_color$id$CEND"
      printf " $date_color$date$CEND"
      printf " $author_color%s$CEND"    "$(FilterJoinChars $author)"
      printf " $pad| $msg_color%s$CEND" "$(FilterJoinChars $msg   )"
      [[ "$ref" != "$JOIN_CHAR" ]] && printf " $ref_color($(FilterJoinChars $ref))$CEND"
      [[ "$sig" != "$JOIN_CHAR" ]] && printf " $sig_color[$(FilterJoinChars $sig)]$CEND"
      printf "\n"
  done

  NCommits=$(( NCommits + ${#Ids[*]} ))
}

PrintWarnings()
{
  if   grep 'gpg.ssh.allowedSignersFile' "$LOG_FILE" &> /dev/null
  then echo -e "\nprocessing was delayed due to misconfigured 'gpg.ssh.allowedSignersFile'"
  fi
}

Main()
{
  local ancestor=$(Ancestor $PUB_BRANCH $REF)
  local n_commits=$N_COMMITS

  if   [[ -z "$PUB_BRANCH" ]]
  then CompileResults < <(GitLog $n_commits            $REF $FILE ; echo ;)
       PrintReport 'NO UPSTREAM'
  elif ! IsAncestor $PUB_BRANCH $REF && [[ -z ${ancestor:-} ]]
  then CompileResults < <(GitLog $n_commits            $REF $FILE ; echo ;)
       PrintReport 'UNRELATED'
  else CompileResults < <(GitLog $n_commits $ancestor..$REF $FILE ; echo ;)
       PrintReport 'UNMERGED'

       if   (( ! HIDE_MERGED && NCommits < N_COMMITS ))
       then n_commits=$(( N_COMMITS - NCommits ))
            CompileResults < <(GitLog $n_commits $ancestor $FILE  ; echo ;)
            PrintReport 'MERGED'
       fi
  fi

  PrintWarnings
}


## main entry ##

Init "$@" && Main
